Skip to content

feat: versioned JobManager for reading/writing jobs across protocol versions#9434

Open
koenvanderveen wants to merge 34 commits into
devfrom
koen/job-reader-writer
Open

feat: versioned JobManager for reading/writing jobs across protocol versions#9434
koenvanderveen wants to merge 34 commits into
devfrom
koen/job-reader-writer

Conversation

@koenvanderveen

@koenvanderveen koenvanderveen commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator
  • Versioned JobManager (nested in JobClient) owns all JobState/JobSubmissionMetadata filesystem IO: reads upgrade objects in memory to the latest registered version, writes downgrade them to the version/layout the reading peer understands. JobClient, JobInfo, SyftJobRunner, the syft-bg job monitor, and the enclave client all route through it.
  • On-disk protocol version: new jobs live under inbox/<ds_email>/v1/<job_name>/ (same for review/). Protocol 0 = last release (0.1.38): no version segment, no canonical_name/version fields in yaml; protocol-0 jobs are read, listed, and written back byte-compatibly with 0.1.38.
  • Per-peer protocol negotiation: JobClient accepts optional peer_schemas (peer email → job ProtocolSchema); the version spoken to a peer is the min of ours and theirs, so an older peer receives the older layout/format. Unknown peers default to the current protocol.
  • syft-migration: MigrationRegistry gains a protocol_version; released schemas are frozen as ReleasedProtocol / ReleasedPackageProtocolInfo artifacts (save/load, registered on import) with drift detection that fails if a released object schema changes without a new version. A hardcoded 0.1.38 artifact ships in syft_job/migrations/history/ as if that release had emitted it.
  • JobInfo identity from the path, not config.yaml: datasite_owner_email, submitted_by, and name now come from the path-derived JobRef (governed by syft permissions), never from the DS-writable config.yaml — closing a spoofing vector where a DS could claim a fake identity. ref is now required; current_user_email stays sourced from the client config.
  • Release tooling (packages/syft-job/scripts/): export_release_artifact.py writes the JSON schema artifacts, and the new generate_release_fixture.py snapshots a full SyftBox tree of exactly how the release serializes jobs to disk. Both are run on every release and documented in CLAUDE.md / README.md.
  • Cross-release on-disk fixtures: fixtures named syft_job-<version>-protocol<p>_syftbox (0.1.38/protocol 0 hand-authored, 0.1.39/protocol 1 generated). test_older_protocol_compatibility.py loops over all of them — keyed on release, not just protocol, since a release can change how it writes bytes without bumping the protocol — asserting load/upgrade plus byte-exact scan/approve write-back per protocol.

Test plan: syft-migration + syft-job migration/p2p suites (list/upgrade, scan, approve write-back byte-match, mixed listing, reserved v<n> job names, per-protocol peer submit, perms on the v1 subfolder, repr smoke test, and an identity-from-path spoofing test) plus root tests/unit; all suites green, pre-commit clean, history JSON verified in the wheel.

koenvanderveen and others added 8 commits July 7, 2026 18:54
Loop the p2p compatibility test over every released on-disk fixture
(keyed on release, e.g. syft_job-0.1.38-protocol0_syftbox) instead of a
single protocol, asserting jobs load/upgrade and that scan/approve write
back byte-exact in each release's layout/format. Also check job reprs
render without error.

Add scripts/generate_release_fixture.py to snapshot the current release's
SyftBox tree (run on every release) and move export_release_artifact.py
alongside it in scripts/. Document both in CLAUDE.md and README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
datasite_owner_email, submitted_by and name now read from the JobRef
(parsed from the folder layout, governed by syft permissions) instead of
the DS-writable config.yaml, so a data scientist can't spoof them.
Make ref required and drop the metadata-based fallback; current_user_email
stays a stored arg from the client config. Add a test proving a tampered
config.yaml name is ignored in favor of the path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…efactor

Pass the path-derived JobRef and drop the removed datasite_owner_email kwarg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@pjwerneck pjwerneck left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments mark some minor issues found in the code.

The main high-level design issue would be having a separate ProtocolCodec class, to avoid bundling all protocol rules in the _load_upgraded and _write_in_target_version methods, as suggested in the comments.

self.service = MigrationService(registry=registry)
# peer email -> job ProtocolSchema; filled in by syft-client later.
# Peers without an entry are assumed to run the current protocol.
self.peer_schemas: dict[str, ProtocolSchema] = peer_schemas or {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While peer_schemas is being assigned here and the versioning machinery treats it correctly, it's apparently not being sent by the syft-client, since JobClient is initialized using .from_config and there's no peer_schemas being passed anywhere.

The test cases are correct and pass peer_schemas, but the actual code doesn't.

# -- model IO ---------------------------------------------------------------
def read_submission(self, ref: JobRef) -> JobSubmissionMetadata:
"""Load a submission's config.yaml, upgraded to the latest version."""
extra = {"submitted_by": ref.ds_email, "datasite_email": ref.datasite_email}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't ref.job_name be added to extra here too, since it comes from the path like submitted_by and datasite_email?

]
if not matches:
raise FileNotFoundError(f"Job '{job_name}' not found")
if len(matches) > 1:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will raise an error with jobs with the same name across protocol versions. Is that intended?

except Exception:
continue
)
except Exception:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blanket exception catches on a long block like this are an anti-pattern. It should catch specific exceptions, or at least log the error with the job name and path.

self,
ds_email: str,
job_name: str,
protocol_version: str = JOB_PROTOCOL_VERSION,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since receive_job is only called by scan_inbox, which already determines the correct protocol_version on load, it should be required parameter, not default to the constant.

# Update state to RUNNING
state_file = review_dir / "state.yaml"
state = JobState.load(state_file)
state = self.manager.read_state(ref)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JobManager.read_state can return None if the state.yaml no longer exists. There should be a check here to prevent an exception on the state.status on the next line.

return path

# -- internals -----------------------------------------------------------------
def _load_upgraded(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both _load_upgraded and _write_in_target_version work for only two versions, but are somewhat fragile when it comes to handling more future versions, and specially once we need to upgrade or downgrade jobs throughout multiple versions.

Instead of conditionals I would introduce a ProtocolCodec class that handles the serialization and loading for each version, and JobManager delegates to the appropriate class, keeping all migration specific code in the codec class. Once v2 is introduced, it's just a new class, instead of more conditionals.

protocol_version: str # "0" (no path segment) or "1"+ (v<n> segment)


class JobManager:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JobManager is a somewhat vague name that blurs the line with JobClient. Although the two have clearly distinct responsibilities, the suffix is not as clear. Maybe JobRepository or JobStorage.

return None
return self._load_upgraded(path, "JobState", {})

def write_submission(self, ref: JobRef, metadata: JobSubmissionMetadata) -> Path:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both write_submission and write_state could be renamed to make the asymmetry more explicit, like in the docstring. Like write_submission_for_do and write_state_for_ds.

if review_state.status == JobStatus.FAILED:
self.state.mark_notified(metadata.name, "failed")
count += 1
self.state.mark_notified(metadata.name, "new")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metadata.name was replaced with ref.job_name in most places, but still remains in others like here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants